"""
09_batch_operations.py
======================
High-Performance Batch Operations

This module covers:
1. UNWIND for bulk inserts
2. PERIODIC COMMIT
3. CALL IN TRANSACTIONS
4. CSV imports
5. Parallel processing

Author: Neo4j Tutorial
Date: January 2025
"""

from typing import Dict, List, Any, Generator, Optional
from dataclasses import dataclass
import time
import csv
import io
import sys
import os

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.utils.connection import get_driver


@dataclass
class BatchResult:
    """Result of a batch operation."""
    total_processed: int
    batches_completed: int
    elapsed_seconds: float
    records_per_second: float


class BatchOperations:
    """
    High-performance batch operations for Neo4j.
    
    Batch Processing Strategies:
    
    ┌─────────────────────────────────────────────────────────────────┐
    │                    BATCH STRATEGIES                             │
    ├─────────────────────────────────────────────────────────────────┤
    │                                                                 │
    │  UNWIND Pattern                                                 │
    │  ──────────────                                                 │
    │  UNWIND $items AS item                                         │
    │  CREATE (n:Node {prop: item.prop})                             │
    │                                                                 │
    │  • Best for < 10K items per transaction                        │
    │  • Single transaction, atomic                                  │
    │  • Memory efficient                                            │
    │                                                                 │
    │  CALL IN TRANSACTIONS                                          │
    │  ─────────────────────                                          │
    │  CALL { ... } IN TRANSACTIONS OF 1000 ROWS                     │
    │                                                                 │
    │  • Best for > 10K items                                        │
    │  • Auto-commits in batches                                     │
    │  • Survives OOM                                                │
    │                                                                 │
    │  LOAD CSV                                                      │
    │  ────────                                                       │
    │  LOAD CSV WITH HEADERS FROM 'file:///data.csv'                 │
    │                                                                 │
    │  • Best for file-based imports                                 │
    │  • Streaming (low memory)                                      │
    │  • Direct file access                                          │
    │                                                                 │
    │  Performance Tips:                                              │
    │  • Create indexes BEFORE bulk load                             │
    │  • Use MERGE for idempotent operations                         │
    │  • Batch size 1000-10000 typically optimal                     │
    │  • Monitor with EXPLAIN/PROFILE                                │
    │                                                                 │
    └─────────────────────────────────────────────────────────────────┘
    """
    
    def __init__(self, driver=None):
        self.driver = driver or get_driver()
    
    # =========================================================
    # UNWIND PATTERN
    # =========================================================
    
    def bulk_create_nodes(
        self,
        label: str,
        items: List[Dict],
        batch_size: int = 1000
    ) -> BatchResult:
        """
        Bulk create nodes using UNWIND pattern.
        
        Args:
            label: Node label
            items: List of dictionaries with properties
            batch_size: Items per transaction
            
        Returns:
            BatchResult with statistics
        """
        start_time = time.time()
        total_processed = 0
        batches = 0
        
        # Build dynamic property mapping
        if items:
            props = list(items[0].keys())
            prop_str = ", ".join([f"{p}: item.{p}" for p in props])
        else:
            return BatchResult(0, 0, 0, 0)
        
        query = f"""
        UNWIND $items AS item
        CREATE (n:{label} {{{prop_str}}})
        SET n.createdAt = datetime()
        RETURN count(n) as created
        """
        
        with self.driver.session() as session:
            for i in range(0, len(items), batch_size):
                batch = items[i:i + batch_size]
                result = session.run(query, items=batch)
                total_processed += result.single()["created"]
                batches += 1
        
        elapsed = time.time() - start_time
        
        return BatchResult(
            total_processed=total_processed,
            batches_completed=batches,
            elapsed_seconds=round(elapsed, 3),
            records_per_second=round(total_processed / elapsed, 2) if elapsed > 0 else 0
        )
    
    def bulk_create_with_merge(
        self,
        label: str,
        items: List[Dict],
        key_property: str,
        batch_size: int = 1000
    ) -> BatchResult:
        """
        Bulk create/update nodes using MERGE (idempotent).
        
        Args:
            label: Node label
            items: List of dictionaries
            key_property: Property to match on
            batch_size: Items per transaction
            
        MERGE creates if not exists, matches if exists.
        """
        start_time = time.time()
        total_processed = 0
        batches = 0
        
        # Properties except the key
        if items:
            props = [k for k in items[0].keys() if k != key_property]
            set_str = ", ".join([f"n.{p} = item.{p}" for p in props])
        else:
            return BatchResult(0, 0, 0, 0)
        
        query = f"""
        UNWIND $items AS item
        MERGE (n:{label} {{{key_property}: item.{key_property}}})
        SET {set_str}, n.updatedAt = datetime()
        RETURN count(n) as processed
        """
        
        with self.driver.session() as session:
            for i in range(0, len(items), batch_size):
                batch = items[i:i + batch_size]
                result = session.run(query, items=batch)
                total_processed += result.single()["processed"]
                batches += 1
        
        elapsed = time.time() - start_time
        
        return BatchResult(
            total_processed=total_processed,
            batches_completed=batches,
            elapsed_seconds=round(elapsed, 3),
            records_per_second=round(total_processed / elapsed, 2) if elapsed > 0 else 0
        )
    
    # =========================================================
    # BULK RELATIONSHIPS
    # =========================================================
    
    def bulk_create_relationships(
        self,
        from_label: str,
        to_label: str,
        rel_type: str,
        relationships: List[Dict],
        batch_size: int = 1000
    ) -> BatchResult:
        """
        Bulk create relationships.
        
        Args:
            from_label: Start node label
            to_label: End node label
            rel_type: Relationship type
            relationships: List of dicts with 'from_id', 'to_id', and optional properties
            batch_size: Relationships per transaction
            
        Expected relationship format:
        {
            'from_id': 'P001',
            'to_id': 'E11',
            'prop1': 'value1',  # optional
        }
        """
        start_time = time.time()
        total_processed = 0
        batches = 0
        
        # Get additional properties
        extra_props = []
        if relationships:
            extra_props = [k for k in relationships[0].keys() 
                          if k not in ('from_id', 'to_id')]
        
        prop_str = ""
        if extra_props:
            prop_list = ", ".join([f"{p}: rel.{p}" for p in extra_props])
            prop_str = f" {{{prop_list}}}"
        
        query = f"""
        UNWIND $rels AS rel
        MATCH (a:{from_label} {{id: rel.from_id}})
        MATCH (b:{to_label} {{id: rel.to_id}})
        CREATE (a)-[r:{rel_type}{prop_str}]->(b)
        SET r.createdAt = datetime()
        RETURN count(r) as created
        """
        
        with self.driver.session() as session:
            for i in range(0, len(relationships), batch_size):
                batch = relationships[i:i + batch_size]
                result = session.run(query, rels=batch)
                total_processed += result.single()["created"]
                batches += 1
        
        elapsed = time.time() - start_time
        
        return BatchResult(
            total_processed=total_processed,
            batches_completed=batches,
            elapsed_seconds=round(elapsed, 3),
            records_per_second=round(total_processed / elapsed, 2) if elapsed > 0 else 0
        )
    
    # =========================================================
    # CALL IN TRANSACTIONS (Neo4j 4.4+)
    # =========================================================
    
    def bulk_import_with_transactions(
        self,
        label: str,
        items: List[Dict],
        batch_size: int = 1000
    ) -> BatchResult:
        """
        Bulk import using CALL {} IN TRANSACTIONS.
        
        This is the recommended approach for very large imports
        as it auto-commits in batches and can survive OOM.
        """
        start_time = time.time()
        
        # Build property string
        if items:
            props = list(items[0].keys())
            prop_str = ", ".join([f"{p}: item.{p}" for p in props])
        else:
            return BatchResult(0, 0, 0, 0)
        
        query = f"""
        UNWIND $items AS item
        CALL {{
            WITH item
            CREATE (n:{label} {{{prop_str}}})
            SET n.createdAt = datetime()
            RETURN n
        }} IN TRANSACTIONS OF {batch_size} ROWS
        RETURN count(*) as total
        """
        
        with self.driver.session() as session:
            result = session.run(query, items=items)
            total = result.single()["total"]
        
        elapsed = time.time() - start_time
        
        return BatchResult(
            total_processed=total,
            batches_completed=(total + batch_size - 1) // batch_size,
            elapsed_seconds=round(elapsed, 3),
            records_per_second=round(total / elapsed, 2) if elapsed > 0 else 0
        )
    
    # =========================================================
    # CSV IMPORT
    # =========================================================
    
    def import_from_csv_string(
        self,
        label: str,
        csv_content: str,
        batch_size: int = 1000
    ) -> BatchResult:
        """
        Import data from CSV string content.
        
        For file imports, use LOAD CSV in Cypher directly:
        LOAD CSV WITH HEADERS FROM 'file:///data.csv' AS row
        """
        reader = csv.DictReader(io.StringIO(csv_content))
        items = list(reader)
        
        return self.bulk_create_nodes(label, items, batch_size)
    
    def generate_load_csv_query(
        self,
        label: str,
        csv_url: str,
        property_mapping: Dict[str, str] = None,
        batch_size: int = 1000
    ) -> str:
        """
        Generate a LOAD CSV query.
        
        Args:
            label: Node label
            csv_url: URL to CSV file (file:// or http://)
            property_mapping: Map CSV columns to node properties
            batch_size: Rows per transaction
            
        Returns:
            Cypher query string
        """
        if property_mapping:
            prop_str = ", ".join([
                f"{prop}: row.{csv_col}"
                for prop, csv_col in property_mapping.items()
            ])
        else:
            prop_str = "row"  # Will use column names as properties
        
        query = f"""
        LOAD CSV WITH HEADERS FROM '{csv_url}' AS row
        CALL {{
            WITH row
            CREATE (n:{label} {{{prop_str}}})
            SET n.importedAt = datetime()
            RETURN n
        }} IN TRANSACTIONS OF {batch_size} ROWS
        RETURN count(*) as imported
        """
        
        return query
    
    # =========================================================
    # BULK DELETE
    # =========================================================
    
    def bulk_delete_by_property(
        self,
        label: str,
        property_name: str,
        property_value: Any,
        batch_size: int = 1000
    ) -> int:
        """
        Delete nodes in batches to avoid memory issues.
        """
        total_deleted = 0
        
        query = f"""
        MATCH (n:{label} {{{property_name}: $value}})
        WITH n LIMIT $limit
        DETACH DELETE n
        RETURN count(*) as deleted
        """
        
        with self.driver.session() as session:
            while True:
                result = session.run(query, value=property_value, limit=batch_size)
                deleted = result.single()["deleted"]
                total_deleted += deleted
                
                if deleted < batch_size:
                    break
        
        return total_deleted
    
    def bulk_delete_all(
        self,
        label: str,
        batch_size: int = 10000
    ) -> int:
        """
        Delete all nodes of a label in batches.
        """
        total_deleted = 0
        
        query = f"""
        MATCH (n:{label})
        WITH n LIMIT $limit
        DETACH DELETE n
        RETURN count(*) as deleted
        """
        
        with self.driver.session() as session:
            while True:
                result = session.run(query, limit=batch_size)
                deleted = result.single()["deleted"]
                total_deleted += deleted
                
                if deleted < batch_size:
                    break
        
        return total_deleted
    
    # =========================================================
    # STREAMING / GENERATOR PATTERN
    # =========================================================
    
    def stream_large_result(
        self,
        query: str,
        parameters: Dict = None,
        fetch_size: int = 1000
    ) -> Generator[Dict, None, None]:
        """
        Stream large query results to avoid memory issues.
        
        Uses generator pattern to yield records one at a time.
        """
        with self.driver.session() as session:
            result = session.run(
                query, 
                parameters or {},
                # Configure fetch size
            )
            for record in result:
                yield record.data()
    
    def process_in_batches(
        self,
        query: str,
        parameters: Dict = None,
        batch_size: int = 1000,
        processor: callable = None
    ) -> int:
        """
        Process query results in batches.
        
        Args:
            query: Cypher query
            parameters: Query parameters
            batch_size: Records to process at once
            processor: Function to call for each batch
            
        Returns:
            Total records processed
        """
        batch = []
        total = 0
        
        for record in self.stream_large_result(query, parameters):
            batch.append(record)
            
            if len(batch) >= batch_size:
                if processor:
                    processor(batch)
                total += len(batch)
                batch = []
        
        # Process remaining
        if batch:
            if processor:
                processor(batch)
            total += len(batch)
        
        return total


# ============================================================
# DEMONSTRATION
# ============================================================

if __name__ == "__main__":
    print("=" * 60)
    print("Batch Operations - Demonstration")
    print("=" * 60)
    
    try:
        batch_ops = BatchOperations()
        
        # Generate sample data
        print("\n📌 Generating Sample Data")
        print("-" * 40)
        
        sample_items = [
            {"id": f"BATCH{i:04d}", "name": f"Item {i}", "value": i * 10}
            for i in range(100)
        ]
        print(f"   Generated {len(sample_items)} items")
        
        # Bulk create with UNWIND
        print("\n📌 Bulk Create (UNWIND Pattern)")
        print("-" * 40)
        result = batch_ops.bulk_create_nodes(
            "BatchDemo",
            sample_items,
            batch_size=50
        )
        print(f"   Created: {result.total_processed}")
        print(f"   Batches: {result.batches_completed}")
        print(f"   Time: {result.elapsed_seconds}s")
        print(f"   Rate: {result.records_per_second}/sec")
        
        # Cleanup
        print("\n📌 Cleanup")
        print("-" * 40)
        deleted = batch_ops.bulk_delete_all("BatchDemo")
        print(f"   Deleted {deleted} nodes")
        
        print("\n" + "=" * 60)
        print("✅ Batch operations demonstration complete!")
        print("=" * 60)
        
    except Exception as e:
        print(f"Error: {e}")
